Skip to content

fix(kanban): keep the swimlane column-header row out of the flex shrink pool - #8430

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-7303-kanban-swimlane-header-height
Sep 7, 2026
Merged

fix(kanban): keep the swimlane column-header row out of the flex shrink pool#8430
os-justin merged 1 commit into
mainfrom
claude/issue-7303-kanban-swimlane-header-height

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #7303

What the card said, re-derived on my own base (0203a29e9)

Every number in the card still holds. The three-layer ancestor chain it measured is
literally these lines, and the tag difference it noticed is real:

card's claim on 0203a29e9
header row / cell / title = 0 / 0 / 16 reproduced in Chromium 1194 at 1600×1000, exactly those numbers
elementFromPoint at a title's centre returns the lane <button> reproduced (isSelfOrChild: false, hit: BUTTON.w-full flex items-center…)
<h3> on the plain board, <span> in the swimlane row still true — KanbanImpl.tsx:319 vs :661
grouping.fields[0].field is the only authorable route still true — ObjectKanban.tsx:753 derives swimlaneField ?? grouping.fields[0].field; unchanged by #8313

Root cause: a flexbox rule, not a paint bug

The header row is a flex item of the swimlane region (flex-col, inside a
height-bounded h-full board). overflow-x-auto makes it a scroll container, and a
flex item's automatic minimum size (min-height: auto) applies only while its overflow
is visible
— so a scroll container may legally be shrunk to height 0. The lanes below
keep overflow: visible, so their automatic minimum size clamps them at content height
and they refuse to shrink. Once the lanes overflow the board, the entire deficit lands
on the one shrinkable item
. Measured computed styles on the broken build:

headerRow  flex-shrink 1  min-height auto  overflow-x auto     overflow-y auto     height 0.0
firstLane  flex-shrink 1  min-height auto  overflow-x visible  overflow-y visible  height 674.5

The fix is shrink-0 on that row — one class, no layout decisions taken.

⚠️ The trigger decides who saw this: the collapse needs the lanes to overflow the
board. The same fixture with 45 cards instead of 150 renders the row at 24px on the
broken build. So in the field this presents as "sometimes the column labels are missing",
not as a flat breakage of swimlanes.

Measured, in a real browser, on this component's own markup

Chromium 1194 @ 1600×1000, the real rendered DOM of KanbanRenderer + Tailwind-generated
CSS, lanes overflowing a 1000px board:

before after
header row height 0 24
header cell height 0 24
title span height 16 16
elementFromPoint at title centre lane-collapse <button> the title <span> itself
lane button / card height (control) 32 / 53.3 32 / 53.3

The pin — and what it honestly is

⚠️ A "the header row exists" assertion passes on the broken build — the row was there,
in the right place, holding all five titles, at height 0. And vitest runs in happy-dom,
which performs no layout
: a height assertion there could not fail in either world. So
packages/plugin-kanban/src/__tests__/swimlaneColumnHeaderRow-7303.test.tsx asserts the
style contract that decides the height, stated as an invariant rather than one blessed
spelling: the row must not be both a scroll container and shrinkable (shrink-0,
flex-shrink-0, an inline flexShrink, or a min-h-* floor all satisfy it; overflow-*-hidden
and -clip count as scroll containers, because they zero the auto minimum size too).

The docstring says all of this in the file, including the browser numbers above, so nobody
later reads it as a height measurement.

Ablation — two mutations, from the committed implementation, each restored by state:

mutation worktree hash result
— (HEAD) a834767db17a35db568de9fa73f2362703e0ae05 2 passed
remove shrink-0 (the read site) b67eed7eb85293eaf1491094f1383427e19d3013 × renders above the lanes and stays out of the flex shrink poolAssertionError: the swimlane column-header row is a scroll container AND shrinkable … class was: "flex gap-3 sm:gap-4 pl-36 sm:pl-44 overflow-x-auto"
delete the header row entirely 81d4773821559ac1fa4ee0f3fa370b90ef5cab59 × same caseAssertionError: the swimlane header row should carry the column title Open; row text was "▶ann(1)Alpha deal@ann"

Restore verified by state after each (git hash-object back to the HEAD blob and
git diff HEAD empty), never by an exit code.

Would something strictly worse than the bug pass? No — that is what the second mutation
above tests. Deleting the swimlane header row, or the swimlane layout itself, reddens the
controls: a rendered lane (['▶ann(1)', '▶bob(1)']), a rendered card inside a lane cell,
and the titles read through queryByText into expect(value, message). A separate
NON-REGRESSION case pins the flat board's own <h3 id="kanban-col-open">, so "make
swimlanes render nothing" is not a way through either. The viewport is pinned explicitly
(1600×1000) even though this markup is width-independent — the file says which branch it
describes and that anything width-dependent added later must re-state it.

On the triage seat's boundary #1 (reuse :319 instead of fixing :659)

Confirmed the two header implementations must stay separate, and the reason is now
written at the call site: the swimlane layout has no column components at all — every lane
paints its own row of plain column cells — so the titles have to be drawn once, above every
lane. Reusing KanbanColumnView's <h3> would mean repeating the labels per lane, which is
a product ruling the card does not settle (its Expected says "above the lanes"), so I did
not take it. The comment also pins the shared pl-36 sm:pl-44 indent, and the test asserts
the header row and the lane content rows carry the same indent tokens.

⚠️ Adjacent defects found while measuring — NOT fixed here, and not filed

search_issues returned API rate limit already exceeded for user ID 323634890, so per my
dispatch I did not file unsearched. All three are in my report for the PM to file:

  1. Swimlane lanes below the fold are unreachable. The region is overflow-hidden
    (scrollHeight 2104 vs clientHeight 1000) and no ancestor scrolls
    (document.documentElement is not scrollable) — with 3 lanes at 1600×1000, lanes 2 and 3
    cannot be reached at all. Fixing it means choosing where the swimlane board scrolls, which
    is a product ruling.
  2. The header row and each lane scroll horizontally out of sync. They are independent
    scroll containers: driving one lane to scrollLeft 298 leaves the header at 0, putting
    the "Open" title at x=200 while the Open cell sits at x=-97. So past this fix, a
    scrolled swimlane board shows labels over the wrong columns. Syncing them means deciding
    whether all lanes scroll together — again a product ruling.
  3. The swimlane path ignores the container-aware column sizing. KanbanBoardInner derives
    columnInlineStyle from the board's own measured width and the flat path applies it, but
    the swimlane header cells and lane cells keep the hard-coded w-[85vw] sm:w-80 — so an
    embedded/panelled swimlane board still sizes columns to 85% of the viewport, the exact
    failure that sizing was introduced to remove.

Verification

reading verdict
closure build pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-kanban^...' build exit 0
pnpm exec vitest run packages/plugin-kanban/ (repo root, positional path) Test Files 31 passed (31) · Tests 206 passed (206)
pnpm --filter @object-ui/plugin-kanban type-check (tsc --noEmit && tsc -p tsconfig.test.json) exit 0 — the new pin is in the program (--listFiles hit, with a control)
pnpm --filter @object-ui/plugin-kanban lint (eslint .) exit 0 · 45 files · 0 errors, 163 pre-existing warnings; 2 of the warnings are the house no-explicit-any pattern in the new test, and --max-warnings is deliberately unset in lint.yml
node scripts/check-changeset-presence.mjs ✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7303-kanban-swimlane-column-header-row.md.
node scripts/check-changeset-no-major.mjs ✅ No changeset declares a 'major' bump. (declared patch)
node scripts/check-governed-queue-guard.mjs --test <the 3 paths> ✅ NOT GOVERNED — 3 path(s) checked against 5 governed surface(s); none matched. (control: AGENTS.md reads governed)

Proposal — the standing gap this card exposes

Nothing in CI can measure a rendered height: unit tests run in happy-dom (no layout) and the
e2e suite needs a production build of the console. The measurement above is reproducible but
lives outside the repo — real component DOM + Tailwind-compiled CSS + Chromium, roughly 60
lines. packages/plugin-grid/demo/vite.live.config.ts is the precedent for a standalone
harness. Worth a card if the maintainers want layout regressions of this class caught rather
than argued; I did not file one, for the rate-limit reason above.

Left as a draft for the PM's contract review.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…nk pool

With swimlanes on, the board's column titles stayed in the DOM at height 0 and
painted nothing (objectui#7303). The header row is a flex item of the swimlane
region, and `overflow-x-auto` makes it a scroll container — which zeroes a flex
item's automatic minimum size, so it may legally shrink to 0. The lanes below
stay `overflow: visible`, so their automatic minimum size clamps them at content
height and they refuse to shrink; once the lanes overflowed the bounded board,
the entire deficit landed on the one shrinkable item.

`shrink-0` takes the row out of that pool. Measured in Chromium 1194 at
1600x1000 on the component's own rendered markup: header row 0 -> 24, header
cell 0 -> 24, and `elementFromPoint` at a title's centre returning the
lane-collapse button before and the title itself after.

The pin asserts the style contract that decides the height, not the row's
existence — the row was present and correctly placed on the broken build — and
carries a rendered lane, a rendered card and the flat board's own headings as
controls. happy-dom performs no layout, so a height assertion there could not
fail; the docstring says so and records the out-of-band measurement instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3472.5 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-BbAN-NYC.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 498.55KB 114.03KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 189.15KB 52.56KB
fields (index.js) 243.15KB 61.40KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.52KB 46.34KB
plugin-dashboard (index.js) 131.48KB 34.45KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.68KB 63.94KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 55.39KB 15.73KB
plugin-list (index.js) 113.38KB 27.73KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.46KB 20.80KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-justin
os-justin marked this pull request as ready for review September 7, 2026 23:22
@os-justin
os-justin added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit c4f775a Sep 7, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-7303-kanban-swimlane-header-height branch September 7, 2026 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Kanban swimlanes render the status column-header row at height 0 — the board loses its column labels

2 participants